
################################################################################
# Imports
################################################################################

import Education
import ycp
import re
import csv
import random
import string
from EducationFiltering import EducationFilter
from EducationAccessTimes import AccessTimeRestrictions
from EducationFirewalling import FirewallRules
from EducationGUIHelper import *

ycp.import_module("SCR")
ycp.import_module("FileUtils")

from ycp import SCR, Path, FileUtils

################################################################################
# Constants
################################################################################

DEFAULT_AGE_RANGE_START = 0
DEFAULT_AGE_RANGE_END = 12
DEFAULT_PROFILE_TYPE = 'default'
GROUP_ADD_CMD='/usr/sbin/groupadd'
GROUP_MOD_CMD='/usr/sbin/groupmod'
GROUP_DEL_CMD='/usr/sbin/groupdel'
# Minimum number of columns in the groups CSV file
MIN_IMPORT_GROUP_COLUMNS = 4

# Maximum number of columns in the groups CSV file
MAX_IMPORT_GROUP_COLUMNS = 6

# System group file
GROUP_FILE = "/etc/group"

PROFILE_TYPES = ['default', 'none', 'custom']

# Regex for user and group names.  Must begin with a letter or underscore,
# and consist only of letters, digits, underscores, periods, or hypens.
GROUP_NAME_REGEX = '^[a-zA-Z_][a-zA-Z0-9_.-]*$'

# Minimum group name length
MIN_GROUP_NAME_LENGTH = 2

# Maximum group name length
MAX_GROUP_NAME_LENGTH = 32

# Minimum age in the groups CSV file
MIN_AGE = 0

# Maximum age in the groups CSV file
MAX_AGE = 125

################################################################################
# Classes
################################################################################

class Group:

  def __init__(self, name=None):

    self.is_new = (name == None)
    self.on_disk_name = name
    self.original_name = name
    self.name = (name or '')
    self.friendly_name = ''
    self.age_range_start = DEFAULT_AGE_RANGE_START
    self.age_range_end = DEFAULT_AGE_RANGE_END
    self.members = []
    self.profile_type = DEFAULT_PROFILE_TYPE
    self.sabayon_profile = ''
    self.kiosk_profile = ''
    self.modified = False
    self.original_members = []
    self.filter = EducationFilter()
    self.access_times = AccessTimeRestrictions()
    self.firewall_rules = FirewallRules()
    self.group_name_changed = False
    self.deleted = False
    self.undo_state = None
    self.renamed = False

  def set_name(self, name,loading=False):
    self.name = name
    self.group_name_changed = (name != self.on_disk_name) and not loading
    self.modified = True

    print "Setting group name -- changed: " + str(self.group_name_changed)

  def set_friendly_name(self, friendly_name):
    self.friendly_name = friendly_name
    self.modified = True

  def set_age_range_start(self, age_range_start):
    self.age_range_start = age_range_start
    self.modified = True

  def set_age_range_end(self, age_range_end):
    self.age_range_end = age_range_end
    self.modified = True

  def set_profile_type(self, profile_type):
    self.profile_type = profile_type
    self.modified = True

  def set_sabayon_profile(self, profile):
    self.sabayon_profile = profile
    self.modified = True

  def set_kiosk_profile(self, profile):
    self.kiosk_profile = profile
    self.modified = True
    
  def add_member(self, user, loading=False):
    if not user in self.members:      
      self.members.append(user)

      if (user.group != None):
        user.group.remove_member(user)
        
      if not loading:
        self.modified = True

  def remove_member(self, user):
    if user in self.members:
      self.modified = True
      self.members.remove(user)

  def save_state(self):

    self.undo_state = {}

    self.undo_state['is_new'] = self.is_new
    self.undo_state['on_disk_name'] = self.on_disk_name
    self.undo_state['original_name'] = self.original_name
    self.undo_state['name'] = self.name
    self.undo_state['friendly_name'] = self.friendly_name
    self.undo_state['age_range_start'] = self.age_range_start
    self.undo_state['age_range_end'] = self.age_range_end
    self.undo_state['members'] = list(self.members)
    self.undo_state['profile_type'] = self.profile_type
    self.undo_state['sabayon_profile'] = self.sabayon_profile
    self.undo_state['kiosk_profile'] = self.kiosk_profile
    self.undo_state['modified'] = self.modified    
    self.undo_state['original_members'] = list(self.original_members)
    self.undo_state['group_name_changed'] = self.group_name_changed
    self.undo_state['deleted'] = self.deleted

  def restore_state(self):

    if (self.undo_state == None):
      return

    self.is_new = self.undo_state['is_new']
    self.on_disk_name = self.undo_state['on_disk_name']
    self.original_name = self.undo_state['original_name']
    self.name = self.undo_state['name']
    self.friendly_name = self.undo_state['friendly_name']
    self.age_range_start = self.undo_state['age_range_start']
    self.age_range_end = self.undo_state['age_range_end']
    self.members = list(self.undo_state['members'])
    self.profile_type = self.undo_state['profile_type']
    self.sabayon_profile = self.undo_state['sabayon_profile']
    self.kiosk_profile = self.undo_state['kiosk_profile']
    self.modified = self.undo_state['modified']
    self.original_members = list(self.undo_state['original_members'])
    self.group_name_changed = self.undo_state['group_name_changed']
    self.deleted = self.undo_state['deleted']

    self.undo_state = None

  def get_member_string(self):

    member_list = []

    for user in self.members:
      member_list.append(user.username)

    member_list.sort()
    return ",".join(member_list)

  def get_age_range_string(self):

    if (self.age_range_end == 125):
      return str(self.age_range_start) + " and up"
    else:
      return str(self.age_range_start) + "-" + str(self.age_range_end)

  def validate(self, existing_groups):

    # Stores error messages returned by invalid data
    errors = []

    # Ensure the group name is valid and that it doesn't already exist
    if (len(self.name) == 0):
      errors.append("No group name specified.")
    elif ((len(self.name) < MIN_GROUP_NAME_LENGTH) or (len(self.name) > MAX_GROUP_NAME_LENGTH)):
      errors.append("Invalid group name length.  Must be between %d and %d." % (MIN_GROUP_NAME_LENGTH, MAX_GROUP_NAME_LENGTH))
    elif (re.match(GROUP_NAME_REGEX, self.name) == None):
      errors.append("Invalid group name")
    elif ((self.name in existing_groups) and (self.name != self.original_name)):
      errors.append("The specified group name already exists.")

    # Ensure the start age is numeric
    try:
      self.age_range_start = int(self.age_range_start)
    except:
      errors.append("Invalid start age.  Must be between %d and %d." % (MIN_AGE, MAX_AGE))

    # Get the end age and ensure it's numeric
    try:
      self.age_range_end = int(self.age_range_end)
    except:
      errors.append("Invalid end age.  Must be between %d and %d." % (MIN_AGE, MAX_AGE))

    # Swap the age values if the start is greater than the end
    if (self.age_range_start > self.age_range_end):
      temp = self.age_range_start
      self.age_range_start = self.age_range_end
      self.age_range_end = temp

    # Make sure the age range is valid
    if ((self.age_range_start > MAX_AGE) or (self.age_range_end > MAX_AGE) or (self.age_range_start < MIN_AGE) or (self.age_range_end < MIN_AGE)):
      errors.append("Invalid age range.  Must be between %d and %d." % (MIN_AGE, MAX_AGE))

    # Ensure that the Sabayon profile exists, if one was specified
    if ((self.profile_type == 'custom') and (not valid_profile(self.sabayon_profile))):
      errors.append("The specified Sabayon profile does not exist.")

    # Ensure that the Kiosk tool profile exists, if one was specified
    if ((self.profile_type == 'custom') and (not valid_profile(self.kiosk_profile))):
      errors.append("The specified Kiosk Tool profile does not exist.")

    return (len(errors) == 0, errors)

  def delete(self):
    self.deleted = True

  def save(self):

    for user in self.members:
      user.group = self
      
    if (self.is_new):
      Education.get_config('groups')[self.name] = self
    else:
      # Rename the group
      Education.get_config('groups').pop(self.original_name)
      Education.get_config('groups')[self.name] = self

    self.original_name = self.name

  def write_to_disk(self):

    path = ".edugroup.value." + self.name

    SCR.Write(Path(path + ".FRIENDLY_NAME"), self.friendly_name)    # Should be written to /etc/group
    SCR.Write(Path(path + ".AGE_RANGE_START"), self.age_range_start)
    SCR.Write(Path(path + ".AGE_RANGE_END"), self.age_range_end)
    SCR.Write(Path(path + ".PROFILE_TYPE"), self.profile_type)
    SCR.Write(Path(path + ".SABAYON_PROFILE"), self.sabayon_profile)
    SCR.Write(Path(path + ".KIOSK_PROFILE"), self.kiosk_profile)

    self.filter.write_to_disk(self.name)
    self.access_times.write_to_disk(self.name)
    self.firewall_rules.write_to_disk(self.name)

    if (self.is_new) or ((self.name not in get_system_groups()) and not self.renamed):

        SCR.Execute(Path(".target.bash"), GROUP_ADD_CMD + " " + self.name)   

    elif (self.group_name_changed):

      print "Renaming " + self.on_disk_name + " to " + self.name
      SCR.Execute(Path(".target.bash"), GROUP_MOD_CMD + " -n " + self.name + " " + self.on_disk_name)

################################################################################
# Module functions
################################################################################

def get_groups_table_data():

  groups = []

  for group_name in Education.get_config('groups'):

    group = Education.get_config('groups')[group_name]

    if group.deleted:
      continue
      
    group_name_cell = icon_cell("yast-education-group.png", group_name)
    groups.append(item(group_name, [group_name_cell, group.friendly_name, group.get_age_range_string(), group.get_member_string()]))

  return groups

# Returns a list of all group names (system and education groups)
def get_existing_groups():

  saved_education_groups = []
  current_education_groups = []

  for group_name in Education.get_config('groups'):
    group = load_group(group_name)
    saved_education_groups.append(group.on_disk_name)
    current_education_groups.append(group_name)

  for group in Education.get_config('deleted_groups'):
    saved_education_groups.append(group.on_disk_name)

  # Forget about the on disk education group names since they might have been renamed
  existing_groups = list(set(get_system_groups()) - set(saved_education_groups))

  # Add in their current names
  existing_groups.extend(current_education_groups)


  
  # Sort the groups list and return it
  existing_groups.sort()

  print existing_groups
  return existing_groups

# Returns a list of all education groups
def get_education_groups():
  groups = Education.get_config('groups').keys()
  groups.sort()

  for group_name in Education.get_config('groups').keys():
    group = load_group(group_name)
    if (group.deleted):
      groups.remove(group_name)
  
  return groups

# Retrieves a list of all group names in /etc/group
def get_system_groups():

  groups = []
  content = SCR.Read(Path('.target.string'), GROUP_FILE).splitlines()

  for row in content:
    groups.append(row.split(":")[0].strip())

  groups.sort()
  return groups
    
def load_group(group_name):

  if (Education.get_config('groups').has_key(group_name)):
    return Education.get_config('groups')[group_name]
  else:
    return None

def delete_groups():

  for group in Education.get_config('deleted_groups'):  

    if (group.deleted) and not (group.is_new):
      SCR.Execute(Path(".target.bash"), GROUP_DEL_CMD + " " + group.on_disk_name)
      SCR.Execute(Path(".target.remove"),"/etc/sysconfig/education/groups/" + group.on_disk_name)      

# If a group name is changed, we will first rename the group to a temporary name
# before renaming it to the new desired group name (done in Group.save())
# This is done for safety in the event that group1 was renamed to group2,
# and group2 was renamed to group1.  For efficiency, we could calculate a dependency
# graph, but this should suffice and guarantees safety.  As well, inefficiency shouldn't
# be too much of an issue since renaming groups is a relatively rare occurrence
def rename_groups():
  
  system_groups = get_system_groups()

  for group_name in Education.get_config('groups'):

    group = load_group(group_name)

    # If we have a group whose group name was changed, who is not new, and who actually exists on disk...
    # Not being a new group and existing on disk are not necessarily the same thing.  After all, someone
    # could have deleted the group with groupdel but left the group name in the education configuration files
    if (group.group_name_changed) and not (group.is_new) and (group.on_disk_name in system_groups):
  
      temp_group_name = get_temp_group_name()

      command = GROUP_MOD_CMD + " "                          # Modify the group
      command += "-n " + temp_group_name + " "               # Rename the group to the temporary group name
      command += group.on_disk_name                          # Old group name

      print "Renaming group " + group.on_disk_name + " to " + temp_group_name
      system_groups.remove(group.on_disk_name)
      SCR.Execute(Path(".target.bash"), command)
      SCR.Execute(Path(".target.remove"),"/etc/sysconfig/education/groups/" + group.on_disk_name)
      group.on_disk_name = temp_group_name
      group.renamed = True
      system_groups.append(temp_group_name)

# Returns a unique group name that does not exist on disk
def get_temp_group_name():

  system_groups = get_system_groups()
  temp_group_name = ''.join([random.choice(string.ascii_letters + string.digits) for x in xrange(8)])

  while ((temp_group_name in system_groups) or
        (not re.match(GROUP_NAME_REGEX, temp_group_name))):
    temp_group_name = ''.join([random.choice(string.ascii_letters + string.digits) for x in xrange(8)])

  return temp_group_name

def commit_group_changes():

  # Remove all groups that were deleted
  delete_groups()

  # Temporarily rename "renamed" groups to avoid conflicts
  rename_groups()

  for group_name in Education.get_config('groups'):
    group = load_group(group_name)
    group.write_to_disk()

  print "Done group changes"
  
def commit_user_group_changes():
  for group_name in Education.get_config('groups'):
    group = load_group(group_name)

    if (group.modified):

      added = set(group.members) - set(group.original_members)
      removed = set(group.original_members) - set(group.members)

      for user in added:

        # If the user was deleted, then he/she has already been removed from the group
        if user.deleted:
          continue
          
        SCR.Execute(Path(".target.bash"), GROUP_MOD_CMD + " -A " + user.username + " " + group.name)

      for user in removed:
        SCR.Execute(Path(".target.bash"), GROUP_MOD_CMD + " -R " + user.username + " " + group.name)

def delete_group(group_name):
  
  group = load_group(group_name)

  for user in group.members:
    user.group = None
    user.modified = True

  group.deleted = True
  Education.get_config('groups').pop(group_name)
  Education.get_config('deleted_groups').append(group)

def valid_profile(profile):

  return ((len(profile) == 0) or (FileUtils.Exists(profile)))

##
# Reads education group data from a CSV file.  The file must be in the format:
#
# GROUP_NAME, FRIENDLY_NAME, AGE_RANGE_START, AGE_RANGE_END,
# SABAYON_PROFILE_PATH, KIOSK_PROFILE_PATH
#
# where the last two options (SABAYON_PROFILE_PATH and KIOSK_PROFILE_PATH) are
# optional.
#
# Parameters:
#
#   filename: The CSV file from which to import
#
# Returns:
#
#   imported_groups: An array of imported Group objects
#   errors: A list of error messages generated while parsing the CSV file
##
def import_groups(filename):

  existing_groups = get_existing_groups()

  # Stores the data for all groups imported
  imported_groups = []

  # Stores error messages returned by invalid data
  errors = []

  # Open the file and initialize the CSV reader
  content = SCR.Read(Path('.target.string'), filename).splitlines()
  reader = csv.reader(content)

  line = 0

  # Iterate through each row in the file
  for row in reader:

    line += 1

    # Check that we have a valid number of columns
    if ((len(row) < MIN_IMPORT_GROUP_COLUMNS) or (len(row) > MAX_IMPORT_GROUP_COLUMNS)):

      errors.append("Line %d: Invalid number of columns -- must be between %d and %d." % (line, MIN_IMPORT_GROUP_COLUMNS, MAX_IMPORT_GROUP_COLUMNS))
      continue

    else:

      # Reverse the columns since pop() operates from the end of the array
      row.reverse()

      # Stores the new group
      group = Group()

      group.set_name(row.pop().strip())
      group.set_friendly_name(row.pop().strip())
      group.set_age_range_start(row.pop().strip())
      group.set_age_range_end(row.pop().strip())

      # If we have more data left in the row, grab the Sabayon profile
      if (len(row) > 0):
        group.set_profile_type('custom')
        group.set_sabayon_profile(row.pop().strip())
      else:
        group.set_profile_type('default')
        group.set_sabayon_profile('')

      # If we still have more data left in the row, grab the Kiosk Tool profile
      if (len(row) > 0):
        group.set_kiosk_profile(row.pop().strip())
      else:
        group.set_kiosk_profile('')

      (is_valid, group_errors) = group.validate(existing_groups)

      if (is_valid):
        # All is well, so add the group to the imported groups map
        imported_groups.append(group)
        existing_groups.append(group.name)
      else:
        for error in group_errors:
          errors.append("%d:%s" % (line, error))

  # Return both the imported groups map and the error list
  return (imported_groups, errors)